Ο ορισμός διαφορετικών χρωμάτων σε κάθε κορυφή των τριγώνων και η παρεμβολή του χρώματος στα εσωτερικά σημεία λειτουργεί αποτελεσματικά μόνο για απλά σχήματα. Για να ζωγραφίσουμε μία ρεαλιστική σκηνή με πολλές λεπτομέρειες, θα χρειαζόταν να ορίσουμε πάρα πολλά τρίγωνα με πάρα πολλές κορυφές και να ξοδέψουμε πολύ χρόνο στην εύρεση του σωστού χρώματος για κάθε κορυφή. Θα ήταν καλύτερο και αποδοτικότερο αν μπορούσαμε να ζωγραφίσουμε μεγάλα τρίγωνα, που είναι γεωμετρικά απλά, και να “κολλήσουμε” επάνω τους, σαν ταπετσαρία, μία εικόνα που να δίνει την εντύπωση μιας εξαιρετικά λεπτομερούς επιφάνειας.
Αυτή ακριβώς η τεχνική χρησιμοποιείται κατά κόρον στον προγραμματισμό γραφικών, οι εικόνες, δε, που εφαρμόζονται επάνω στις απλές γεωμετρικά επιφάνειες ονομάζονται χάρτες υφών (texture maps) ή, απλώς, υφές (textures).
Ας αρχίσουμε ορίζοντας ένα τρίγωνο, επάνω στο οποίο θα εφαρμόσουμε αργότερα το texture μας. Μέσα στο πρόγραμμά μας και πριν την εκτέλεση του κυρίως βρόχου γράφουμε:
// === SHADERS ===
shaderProgram = loadShaders("texture.vertexshader", "texture.fragmentshader");
// === DATA ===
const GLfloat vertices[] = {
0.0f, 0.5f, 0.0f,
0.5f, -0.5f, 0.0f,
-0.5f, -0.5f, 0.0f
};
// === VAOs ===
glGenVertexArrays(1, &triangleVAO);
glBindVertexArray(triangleVAO);
// === VBOs ===
glGenBuffers(1, &verticesVBO);
glBindBuffer(GL_ARRAY_BUFFER, verticesVBO);
glBufferData(GL_ARRAY_BUFFER, sizeof(vertices), vertices, GL_STATIC_DRAW);
glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 0, NULL);
glEnableVertexAttribArray(0);
Στον κυρίως βρόχο του προγράμματος, προσθέτουμε τον απαραίτητο κώδικα για να ζωγραφιστεί το τρίγωνο:
glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);
glUseProgram(shaderProgram);
glBindVertexArray(triangleVAO);
glDrawArrays(GL_TRIANGLES, 0, 3);
glfwSwapBuffers(window);
glfwPollEvents();
Γράφουμε, επίσης, και απλούς vertex shader και fragment shader για την απευθείας ζωγραφική του τριγώνου.
#version 330 core
layout(location = 0) in vec3 vertexPosition;
void main()
{
gl_Position = vec4(vertexPosition, 1.0);
}
#version 330 core
out vec4 color;
void main()
{
color = vec4(0.0, 0.0, 0.0, 1.0);
}
Αν έχουν γίνει όλα σωστά, θα πρέπει, εκτελώντας τον κώδικα να βλέπουμε ένα μαύρο τρίγωνο:
Έστω ότι θέλαμε να επικολλήσουμε στο τρίγωνο που ζωγραφίσαμε την ακόλουθη εικόνα:
Αμέσως συναντάμε το πρώτο πρόβλημα: το σχήμα της εικόνας είναι ορθογώνιο και δεν ταιριάζει ακριβώς επάνω στο τρίγωνο. Γίνεται εμφανές ότι θα πρέπει να συσχετίσουμε κάπως τα σημεία του τριγώνου με τις συντεταγμένες της εικόνας. Επειδή ο συσχετισμός αυτός αν έπρεπε να γίνει για κάθε fragment του τριγώνου θα ήταν εξαιρετικά δύσκολος και έχοντας την εμπειρία χρήσης γνωρισμάτων (attributes) σε κορυφές, θα μπορούσαμε να συσχετίσουμε μόνο τις κορυφές του τριγώνου σε συντεταγμένες της εικόνας, να προωθήσουμε τον συσχετισμό αυτό στον vertex shader ως ένα επιπλέον γνώρισμα των κορυφών και να το στείλουμε στο fragment shader, όπου η τιμή θα υπολογιστεί μέσω παρεμβολής.
Ας ορίσουμε καταρχάς έναν τετριμμένο συσχετισμό του τριγώνου και των συντεταγμένων της εικόνας:
Βλέπουμε πως αναφερόμαστε στις κανονικοποιημένες συντεταγμένες της εικόνας, δηλαδή στο εύρος [0, 1]. Αξίζει να σημειωθεί ότι συχνά, για να μη συγχέονται οι συντεταγμένες ενός texture με τις συντεταγμένες του κόσμου, είθισται να μην αναφερόμαστε στις πρώτες με τα γράμματα X και Y, αλλά με τα γράμματα U και V ή S και T.
Οι χάρτες υφής είναι ένας τρόπος να συσχετίσουμε σύνθετη πληροφορία χρώματος σε ένα απλό σχήμα. Όμως, αυτή δεν είναι η μόνη χρήση τους. Μπορούμε να χρησιμοποιήσουμε χάρτες υφής για να συσχετίσουμε οποιαδήποτε σύνθετη πληροφορία σε απλά σχήματα, ακόμη και πληροφορία που δεν είναι δισδιάστατη. Έτσι προκύπτει η ανάγκη για επέκταση των UV ή ST συντεταγμένων. Οι ST συντεταγμένες επεκτείνονται σε STPQ ενώ οι UV συντεταγμένες δεν έχουν κάποια δεδομένη επέκταση.
Πριν την εκτέλεση του κυρίως βρόχου, ορίζουμε τις UV συντεταγμένες, τις στέλνουμε στην GPU χρησιμοποιώντας ένα νέο VBO και δημιουργούμε ένα νέο γνώρισμα στο VAO ώστε να τις στείλουμε στο vertex shader:
const GLfloat uvs[] = {
0.5f, 1.0f,
1.0f, 0.0f,
0.0f, 0.0f
};
glGenBuffers(1, &uvsVBO);
glBindBuffer(GL_ARRAY_BUFFER, uvsVBO);
glBufferData(GL_ARRAY_BUFFER, sizeof(uvs), uvs, GL_STATIC_DRAW);
glVertexAttribPointer(1, 2, GL_FLOAT, GL_FALSE, 0, NULL);
glEnableVertexAttribArray(1);
Αξίζει να προσέξουμε το 2 στη συνάρτηση glVertexAttribPointer που προκύπτει από το γεγονός ότι σε κάθε κορυφή αντιστοιχούν 2 δεκαδικοί αριθμοί και όχι 3, όπως προηγουμένως.
Επιπλέον, είναι ανάγκη να φορτώσουμε στη μνήμη τα δεδομένα της εικόνας. Αυτό μπορεί να γίνει μέσω της βιβλιοθήκης SOIL (Simple OpenGL Image Library).
texture = loadSOIL("texture.bmp");
Η συνάρτηση loadSOIL φορτώνει στη μνήμη την εικόνα “texture.bmp”. Έπειτα, εκτελεί όλες τις απαραίτητες κλήσεις στην OpenGL για τη δημιουργία ενός texture με αυτή την εικόνα ως περιεχόμενο και επιστρέφει το μη-προσημασμένο ακέραιο αριθμό που αντιστοιχεί στο texture.
Τώρα, κάθε φορά που θέλουμε να ζωγραφίσουμε ένα VAO που χρησιμοποιεί αυτό το texture, θα πρέπει πριν τη χρήση της συνάρτησης glDrawArrays (ή άλλης αντίστοιχης συνάρτησης ζωγραφικής) να ενεργοποιούμε το texture ως εξής:
glBindTexture(GL_TEXTURE_2D, texture);
Όσον αφορά τον vertex shader, θα πρέπει να δεχόμαστε το επιπλέον γνώρισμα των κορυφών (UV συντεταγμένες) και να το προωθούμε στον fragment shader, στον οποίο θα φτάσει μέσω παρεμβολής:
#version 330 core
layout(location = 0) in vec3 vertexPosition;
layout(location = 1) in vec2 vertexUV;
out vec2 uv;
void main()
{
gl_Position = vec4(vertexPosition, 1.0);
uv = vertexUV;
}
Στον fragment shader θα πρέπει να δεχτούμε σαν είσοδο τις UV συντεταγμένες που προώθησε ο vertex shader και να λάβουμε την τιμή του texture στις αντίστοιχες UV συντεταγμένες. Η διαδικασία αυτή λέγεται δειγματοληψία (sampling) και η OpenGL την πραγματοποιεί μέσω ενός αντικειμένου που ονομάζεται δειγματολήπτης (sampler).
#version 330 core
in vec2 uv;
out vec4 color;
uniform sampler2D textureSampler;
void main()
{
color = vec4(texture(textureSampler, uv).rgb, 1.0);
}
Ορίσαμε μία uniform μεταβλητή textureSampler η οποία είναι τύπου sampler2D (επειδή επιθυμούμε να δειγματοληπτήσουμε ένα δισδιάστατο texture). Έπειτα, χρησιμοποιήσαμε τη συνάρτηση texture η οποία λαμβάνει ως ορίσματα έναν δειγματολήπτη και τις συντεταγμένες στις οποίες επιθυμούμε να δειγματοληπτήσουμε το texture και επιστρέφει το χρώμα στο σημείο εκείνο.
Εάν όλα πήγαν καλά, θα μπορούμε να δούμε το τρίγωνό μας με την εικόνα υφής εφαρμοσμένη επάνω του:
Μόνο για αυτή την ενότητα, ας αλλάξουμε τον ορισμό του χρώματος στον fragment shader ως ακολούθως:
color = vec4(texture(textureSampler, vec2(uv.x + 0.3, uv.y)).rgb, 1.0);
Με τον τρόπο αυτό μετατοπίσαμε τις UV συντεταγμένες 0.3 μονάδες προς τα αριστερά, φέρνοντάς τες έξω από το “επιτρεπτό” εύρος [0, 1]. Ας δούμε τι θα ζωγραφιστεί στο δεξί τμήμα του τριγώνου (που λαμβάνει πια τιμές μεγαλύτερες από 1):
Παρατηρούμε ότι εκτός του “επιτρεπτού” εύρους η OpenGL επαναλαμβάνει την ίδια εικόνα (ουσιαστικά, αγνοεί το ακέραιο μέρος των UV συντεταγμένων). Αυτή είναι η προεπιλεγμένη συμπεριφορά της OpenGL όμως αυτή, όπως και πολλές άλλες παραμέτρους της OpenGL που αφορούν τη διαχείριση των textures, μπορούμε να τη μεταβάλουμε.
Η μεταβολή των παραμέτρων των textures γίνεται χρησιμοποιώντας την οικογένεια συναρτήσεων glTexParameter*. Η κατάληξη της συνάρτησης εξαρτάται από τον τύπο της τιμής που θέλουμε να αποδώσουμε στην παράμετρο. Για παράδειγμα, θα μπορούσαμε να χρησιμοποιήσουμε τη συνάρτηση glTexParameteri για να αλλάξουμε τη συμπεριφορά ώστε έξω από το εύρος [0, 1] στον άξονα S (η OpenGL χρησιμοποιεί τα γράμματα S και T αντί των U και V) να έχει ένα σταθερό χρώμα:
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_BORDER);
Το i στο τέλος της συνάρτησης προέρχεται από το integer. Η σταθερά
GL_CLAMP_TO_BORDERέχει οριστεί ως ο ακέραιος αριθμός 33069.
Το αποτέλεσμα είναι να βλέπουμε μαύρο χρώμα στο δεξί μέρος του τριγώνου:
Εάν επιθυμούσαμε να αλλάξουμε το χρώμα εκτός των ορίων, θα χρησιμοποιούσαμε τη συνάρτηση glTexParameterfv:
float borderColor[] = { 0.0f, 0.0f, 1.0f, 1.0f };
glTexParameterfv(GL_TEXTURE_2D, GL_TEXTURE_BORDER_COLOR, borderColor);
Το fv στο τέλος της συνάρτησης προέρχεται από το float vector. Η μεταβλητή
borderColorαναπαριστά ένα διάνυσμα δεκαδικών αριθμών.
Το τρίγωνο στα σημεία στα οποία η συντεταγμένη U είναι μεγαλύτερη από 1 έχει τώρα μπλε χρώμα:
Μέσω των συναρτήσεων
glTexParameter*έχουμε τη δυνατότητα να ρυθμίσουμε πολλές ακόμη παραμέτρους των υφών που χρησιμοποιούμε.
Αναρωτηθήκατε πώς ο δειγματολήπτης γνωρίζει ποιο texture να δειγματοληπτήσει στις συντεταγμένες που δώσαμε στη συνάρτηση texture; Όταν το VAO μας χρησιμοποιεί μόνο ένα texture η απάντηση είναι προφανής: όποιο είναι ενεργό (όποιο, δηλαδή, έχουμε κάνει bind). Τι γίνεται όμως όταν πρέπει να χρησιμοποιήσουμε περισσότερα textures για ένα αντικείμενο; Και γιατί χρησιμοποιήσαμε uniform μεταβλητή; Πού ορίζεται η τιμή της;
Τα παραπάνω ερωτήματα απαντούν οι μονάδες υφής (texture units). Οι μονάδες υφής αντιπροσωπεύουν τις δυνατές τοποθεσίες μνήμης στις οποίες μπορεί να τοποθετηθεί ένα texture. Η ανάθεση ενός texture σε μία μονάδα υφής γίνεται με την κλήση της εντολής glBindTexture. Επειδή η προεπιλεγμένη μονάδα υφής είναι η μονάδα 0, το texture μας ενεργοποιήθηκε σε αυτή τη μονάδα υφής. Θα ήταν, δηλαδή, σωστότερο να γράφαμε στον κυρίως βρόχο του προγράμματος:
glBindVertexArray(triangleVAO);
glActiveTexture(GL_TEXTURE0);
glBindTexture(GL_TEXTURE_2D, texture);
Πρέπει, επίσης, να λάβουμε από το shaderProgram τη θέση της μεταβλητής textureSampler που αντιπροσωπεύει τον δειγματολήπτη:
textureSampler = glGetUniformLocation(shaderProgram, "textureSampler");
και, πριν ζωγραφίσουμε το VAO, να του δώσουμε την τιμή της μονάδας υφής που θέλουμε να δειγματοληπτήσει:
glUniform1i(textureSampler, 0);
Έστω τώρα ότι επιθυμούμε να υπερθέσουμε ένα δεύτερο texture επάνω στο προηγούμενο:
Στον fragment shader θα ορίσουμε μία δεύτερη μεταβλητή τύπου sampler2D, η οποία θα δειγματοληπτεί το άλλο texture.
#version 330 core
in vec2 uv;
out vec4 color;
uniform sampler2D textureSampler;
uniform sampler2D otherTextureSampler;
void main()
{
vec4 main_texture = vec4(texture(textureSampler, uv).rgb, 1.0);
vec4 other_texture = vec4(texture(otherTextureSampler, uv).rgb, 1.0);
color = mix(main_texture, other_texture, 0.7);
}
Η συνάρτηση mix που χρησιμοποιήσαμε παραπάνω δημιουργεί μία γραμμική παρεμβολή των περιεχομένων του πρώτου και του δευτέρου ορίσματος με το ποσοστό που δίνουμε στο τρίτο όρισμα. Εδώ, το τελικό χρώμα του fragment θα αποτελείται κατά 30% από το χρώμα που λάβαμε από το main_texture και κατά 70% από το χρώμα που λάβαμε από το other_texture.
Σε αυτή την περίπτωση, χρησιμοποιήσαμε τις ίδιες UV συντεταγμένες για τις δύο εικόνες. Είναι, όμως, σαφές ότι θα μπορούσαμε να είχαμε χρησιμοποιήσει διαφορετικές UV συντεταγμένες για κάθε εικόνα ορίζοντάς τες ως δύο διαφορετικά γνωρίσματα των κορυφών του τριγώνου.
Στην αρχή του προγράμματός μας, θα πρέπει τώρα να φορτώνουμε και το άλλο texture και να αποθηκεύουμε και την τοποθεσία του άλλου δειγματολήπτη:
texture = loadSOIL("texture.bmp");
otherTexture = loadSOIL("other_texture.bmp");
textureSampler = glGetUniformLocation(shaderProgram, "textureSampler");
otherTextureSampler = glGetUniformLocation(shaderProgram, "otherTextureSampler");
Τέλος, κάθε φορά που θέλουμε να ζωγραφίσουμε το triangleVAO, θα πρέπει:
glBindVertexArray(triangleVAO);
// === texture ===
glActiveTexture(GL_TEXTURE0);
glBindTexture(GL_TEXTURE_2D, texture);
glUniform1i(textureSampler, 0);
// === other_texture ===
glActiveTexture(GL_TEXTURE1);
glBindTexture(GL_TEXTURE_2D, otherTexture);
glUniform1i(otherTextureSampler, 1);
glDrawArrays(GL_TRIANGLES, 0, 3);
Το αποτέλεσμα της εκτέλεσης του κώδικα φαίνεται παρακάτω:
LearnOpenGL: Textures
open.gl: Textures
opengl-tutorial: A Textured Cube
ogldev: Basic Texture Mapping
Anton Gerdelan: “Anton’s OpenGL 4 Tutorials”, pp. 170–184, 248–251